#!/usr/bin/env yarn --silent ts-node --transpile-only

import spawnAsync from '@expo/spawn-async';
import { rmSync, existsSync } from 'fs';
import fs from 'fs/promises';
import { glob } from 'glob';
import nullthrows from 'nullthrows';
import path from 'path';

/*
 * Change this to your own Expo account name
 */
export const EXPO_ACCOUNT_NAME = process.env.EXPO_ACCOUNT_NAME || 'myusername';

/**
 * Repository root directory
 */
export const repoRoot = nullthrows(
  process.env.EXPO_REPO_ROOT || process.env.EAS_BUILD_WORKINGDIR,
  'EXPO_REPO_ROOT is not defined'
);

const dirName = __dirname; /* eslint-disable-line */

// Package dependencies in chunks based on peer dependencies.
function getExpoDependencyChunks({
  includeDevClient,
  includeTV,
  includeSplashScreen,
}: {
  includeDevClient: boolean;
  includeTV: boolean;
  includeSplashScreen: boolean;
}) {
  return [
    ['@expo/config-types', '@expo/env', '@expo/json-file'],
    ['@expo/config'],
    ['@expo/config-plugins'],
    ['@expo/plist'],
    ['@expo/local-build-cache-provider'],
    ['expo-modules-core'],
    ['unimodules-app-loader'],
    ['expo-task-manager'],
    ['@expo/cli', 'expo', 'expo-asset', 'expo-modules-autolinking'],
    ['expo-manifests'],
    ['@expo/prebuild-config', '@expo/metro-config', 'expo-constants'],
    ['@expo/image-utils'],
    [
      'babel-preset-expo',
      'expo-application',
      'expo-device',
      'expo-eas-client',
      'expo-file-system',
      'expo-font',
      'expo-json-utils',
      'expo-keep-awake',
      'expo-status-bar',
      'expo-structured-headers',
      'expo-updates',
      'expo-updates-interface',
    ],
    ...(includeSplashScreen ? [['expo-splash-screen']] : []),
    ...(includeDevClient || includeTV
      ? [['expo-dev-menu-interface'], ['expo-dev-menu'], ['expo-dev-launcher'], ['expo-dev-client']]
      : []),
    ...(includeTV
      ? [
          [
            'expo-app-integrity',
            'expo-audio',
            'expo-background-task',
            'expo-blur',
            'expo-crypto',
            'expo-image',
            'expo-image-loader',
            'expo-image-manipulator',
            'expo-insights',
            'expo-linear-gradient',
            'expo-linking',
            'expo-localization',
            'expo-media-library',
            'expo-network',
            'expo-secure-store',
            'expo-sqlite',
            'expo-symbols',
            'expo-system-ui',
            'expo-ui',
            'expo-video',
            'expo-video-thumbnails',
          ],
        ]
      : []),
  ];
}

function getExpoDependencyNamesForDependencyChunks(expoDependencyChunks: string[][]): string[] {
  return expoDependencyChunks.flat();
}

const expoResolutions: { [key: string]: string } = {};

/**
 * Executes `npm pack` on one of the Expo packages used in updates E2E
 * Adds a dateTime stamp to the version to ensure that it is unique and that
 * only this version will be used when yarn installs dependencies in the test app.
 */
async function packExpoDependency(
  repoRoot: string,
  projectRoot: string,
  destPath: string,
  dependencyName: string
) {
  // Pack up the named Expo package into the destination folder
  const dependencyComponents = dependencyName.split('/');
  let dependencyPath: string;
  if (dependencyComponents[0] === '@expo') {
    dependencyPath = path.resolve(
      repoRoot,
      'packages',
      dependencyComponents[0],
      dependencyComponents[1]
    );
  } else {
    dependencyPath = path.resolve(repoRoot, 'packages', dependencyComponents[0]);
  }

  // Save a copy of package.json
  const packageJsonPath = path.resolve(dependencyPath, 'package.json');
  const packageJsonCopyPath = `${packageJsonPath}-original`;
  await fs.copyFile(packageJsonPath, packageJsonCopyPath);
  // Extract the version from package.json
  const packageJson = require(packageJsonPath);
  const originalVersion = packageJson.version;
  // Add string to the version to ensure that yarn uses the tarball and not the published version
  const e2eVersion = `${originalVersion}-${new Date().getTime()}`;
  await fs.writeFile(
    packageJsonPath,
    JSON.stringify(
      {
        ...packageJson,
        version: e2eVersion,
      },
      null,
      2
    )
  );

  let dependencyTarballPath: string;
  try {
    dependencyTarballPath = await spawnNpmPackAsync({ cwd: dependencyPath, outputDir: destPath });
  } finally {
    // Restore the original package JSON
    await fs.copyFile(packageJsonCopyPath, packageJsonPath);
    await fs.rm(packageJsonCopyPath);
  }

  // Return the dependency in the form needed by package.json, as a relative path
  const dependency = `.${path.sep}${path.relative(projectRoot, dependencyTarballPath)}`;

  return {
    dependency,
    e2eVersion,
  };
}

async function spawnNpmPackAsync({
  cwd,
  outputDir,
}: {
  cwd: string;
  outputDir: string;
}): Promise<string> {
  const { stdout } = await spawnAsync(
    'npm',
    // Run `npm pack --json` without the script logging (see: https://github.com/npm/cli/issues/7354)
    ['--foreground-scripts=false', 'pack', '--json', '--pack-destination', outputDir],
    { cwd, stdio: 'pipe' }
  );

  // Validate the tarball output data
  const output = JSON.parse(stdout);
  if (output.length !== 1) {
    throw new Error(`Expected a single tarball to be created, received: ${output.length}`);
  }

  // Return the absolute path to the tarball
  return path.join(outputDir, output[0].filename);
}

async function copyCommonFixturesToProject(
  projectRoot: string,
  fileList: string[],
  {
    appJsFileName,
    repoRoot,
    isTV = false,
  }: { appJsFileName: string; repoRoot: string; isTV: boolean }
) {
  // copy App.tsx from test fixtures
  const appJsSourcePath = path.resolve(dirName, '..', 'fixtures', appJsFileName);
  const appJsDestinationPath = path.resolve(projectRoot, 'App.tsx');
  let appJsFileContents = await fs.readFile(appJsSourcePath, 'utf-8');
  appJsFileContents = appJsFileContents
    .replace('UPDATES_HOST', nullthrows(process.env.UPDATES_HOST))
    .replace('UPDATES_PORT', nullthrows(process.env.UPDATES_PORT));
  await fs.writeFile(appJsDestinationPath, appJsFileContents, 'utf-8');

  // pack up project files
  const projectFilesSourcePath = path.join(dirName, '..', 'fixtures', 'project_files');
  const projectFilesTarballPath = path.join(projectRoot, 'project_files.tgz');
  const tarArgs = ['zcf', projectFilesTarballPath, ...fileList];

  await spawnAsync('tar', tarArgs, {
    cwd: projectFilesSourcePath,
    stdio: 'inherit',
  });

  // unpack project files in project directory
  await spawnAsync('tar', ['zxf', projectFilesTarballPath], {
    cwd: projectRoot,
    stdio: 'inherit',
  });

  // remove project files archive
  await fs.rm(projectFilesTarballPath);

  // copy .prettierrc
  await fs.copyFile(path.resolve(repoRoot, '.prettierrc'), path.join(projectRoot, '.prettierrc'));

  if (!isTV) {
    // Copy react-native patch
    await fs.mkdir(path.join(projectRoot, 'patches'));
    const patchFile = await glob('react-native+*.patch', {
      cwd: path.join(repoRoot, 'patches'),
      absolute: true,
    });
    if (patchFile.length > 0) {
      await fs.copyFile(
        patchFile[0],
        path.join(projectRoot, 'patches', path.basename(patchFile[0]))
      );
    }
  }

  // Modify specific files for TV
  if (isTV) {
    // Add TV environment variable to EAS build config
    const easJsonPath = path.resolve(projectRoot, 'eas.json');
    let easJson = require(easJsonPath);
    easJson = {
      ...easJson,
      build: {
        ...easJson.build,
        updates_testing_debug: {
          ...easJson.build.updates_testing_debug,
          env: {
            ...easJson.build.updates_testing_debug.env,
            TEST_TV_BUILD: '1',
          },
        },
        updates_testing_release: {
          ...easJson.build.updates_testing_release,
          env: {
            ...easJson.build.updates_testing_release.env,
            TEST_TV_BUILD: '1',
          },
        },
      },
    };
    await fs.rm(easJsonPath);
    await fs.writeFile(easJsonPath, JSON.stringify(easJson, null, 2), { encoding: 'utf-8' });
  }
}

/**
 * Adds all the dependencies and other properties needed for the E2E test app
 */
async function preparePackageJson(
  projectRoot: string,
  repoRoot: string,
  configureE2E: boolean,
  isTV: boolean,
  shouldGenerateTestUpdateBundles: boolean,
  includeDevClient: boolean,
  useCustomInit: boolean
) {
  // Create the project subfolder to hold NPM tarballs built from the current state of the repo
  const dependenciesPath = path.join(projectRoot, 'dependencies');
  await fs.mkdir(dependenciesPath);

  const allDependencyChunks = getExpoDependencyChunks({
    includeDevClient,
    includeTV: isTV,
    includeSplashScreen: !useCustomInit,
  });

  console.time('Done packing dependencies');
  for (const dependencyChunk of allDependencyChunks) {
    await Promise.all(
      dependencyChunk.map(async (dependencyName) => {
        console.log(`Packing ${dependencyName}...`);
        console.time(`Packaged ${dependencyName}`);
        const result = await packExpoDependency(
          repoRoot,
          projectRoot,
          dependenciesPath,
          dependencyName
        );
        expoResolutions[dependencyName] = result.dependency;
        console.timeEnd(`Packaged ${dependencyName}`);
      })
    );
  }
  console.timeEnd('Done packing dependencies');

  const extraScriptsGenerateTestUpdateBundlesPart = shouldGenerateTestUpdateBundles
    ? {
        'generate-test-update-bundles': 'npx ts-node ./scripts/generate-test-update-bundles',
      }
    : {
        'generate-test-update-bundles': 'echo 1',
      };

  const extraScriptsAssetExclusion = {
    'reset-to-embedded':
      'npx ts-node ./scripts/reset-app.ts App.tsx.embedded; (rm -rf android/build android/app/build)',
    'set-to-update-1':
      'npx ts-node ./scripts/reset-app.ts App.tsx.update1; eas update --branch=main --message=Update1',
    'set-to-update-2':
      'npx ts-node ./scripts/reset-app.ts App.tsx.update2; eas update --branch=main --message=Update2',
  };

  // Additional scripts and dependencies for Maestro testing
  const extraScripts = configureE2E
    ? {
        start: 'expo start --private-key-path ./keys/private-key.pem',
        'ios:pod-install-old-arch': 'npx pod-install',
        'ios:pod-install': 'RCT_USE_PREBUILT_RNCORE=1 RCT_USE_RN_DEP=1 npx pod-install',
        'maestro:android:debug:build': 'cd android; ./gradlew :app:assembleDebug; cd ..',
        'maestro:android:debug:install':
          'adb install android/app/build/outputs/apk/debug/app-debug.apk',
        'maestro:android:release:build': 'cd android; ./gradlew :app:assembleRelease; cd ..',
        'maestro:android:release:install':
          'adb install android/app/build/outputs/apk/release/app-release.apk',
        'maestro:android:uninstall': 'adb uninstall dev.expo.updatese2e',
        'maestro:ios:debug:build':
          'set -o pipefail && xcodebuild -workspace ios/updatese2e.xcworkspace -scheme updatese2e -configuration Debug -sdk iphonesimulator -arch arm64 -derivedDataPath ios/build | npx @expo/xcpretty',
        'maestro:ios:debug:install':
          'xcrun simctl install booted ios/build/Build/Products/Debug-iphonesimulator/updatese2e.app',
        'maestro:ios:release:build':
          'set -o pipefail && xcodebuild -workspace ios/updatese2e.xcworkspace -scheme updatese2e -configuration Release -sdk iphonesimulator -arch arm64 -derivedDataPath ios/build | npx @expo/xcpretty',
        'maestro:ios:release:install':
          'xcrun simctl install booted ios/build/Build/Products/Release-iphonesimulator/updatese2e.app',
        'maestro:ios:uninstall': 'xcrun simctl uninstall booted dev.expo.updatese2e',
        'eas-build-pre-install': './eas-hooks/eas-build-pre-install.sh',
        'eas-build-on-success': './eas-hooks/eas-build-on-success.sh',
        'check-android-emulator': 'npx ts-node ./scripts/check-android-emulator.ts',
        'tvos:build':
          'set -o pipefail && xcodebuild -workspace ios/updatese2e.xcworkspace -scheme updatese2e -configuration Debug -sdk appletvsimulator -arch arm64 -derivedDataPath ios/build | npx @expo/xcpretty',
        postinstall: 'patch-package',
        'start:dev-client':
          'npx expo start --private-key-path ./keys/private-key.pem > /dev/null 2>&1 &',
        ...extraScriptsGenerateTestUpdateBundlesPart,
      }
    : extraScriptsAssetExclusion;

  const extraDevDependencies = configureE2E
    ? {
        '@config-plugins/detox': '^9.0.0',
        '@types/express': '^5.0.3',
        express: '^5.1.0',
        'form-data': '^4.0.0',
        prettier: '^2.8.1',
        'patch-package': '^8.0.0',
      }
    : {};

  // Remove the default Expo dependencies from create-expo-app
  let packageJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf-8'));
  for (const dependencyName of getExpoDependencyNamesForDependencyChunks(allDependencyChunks)) {
    if (packageJson.dependencies[dependencyName]) {
      delete packageJson.dependencies[dependencyName];
    }
  }
  // Add dependencies and resolutions to package.json
  packageJson = {
    ...packageJson,
    scripts: {
      ...packageJson.scripts,
      ...extraScripts,
    },
    dependencies: {
      ...expoResolutions,
      ...packageJson.dependencies,
    },
    devDependencies: {
      '@types/react': '~19.0.10',
      ...extraDevDependencies,
      ...packageJson.devDependencies,
      'ts-node': '10.9.2',
      typescript: '5.8.3',
    },
    resolutions: {
      ...expoResolutions,
      ...packageJson.resolutions,
      typescript: '5.8.3',
      '@isaacs/cliui': 'npm:cliui@8.0.1', // Fix string-width ESM error
    },
  };

  if (isTV) {
    packageJson = {
      ...packageJson,
      dependencies: {
        ...packageJson.dependencies,
        'react-native': 'npm:react-native-tvos@0.82.0-0rc5',
        '@react-native-tvos/config-tv': '^0.1.4',
      },
      expo: {
        install: {
          exclude: ['react-native', 'typescript'],
        },
      },
    };
  }

  const packageJsonString = JSON.stringify(packageJson, null, 2);
  await fs.writeFile(path.join(projectRoot, 'package.json'), packageJsonString, 'utf-8');
}

/**
 * Adds our E2E test native modules to both iOS and Android expo-updates code.
 * Returns a function that cleans up these changes to the repo once E2E setup is complete
 */
async function prepareLocalUpdatesModule(repoRoot: string) {
  // copy UpdatesE2ETest exported module into the local package
  const iosE2ETestModuleSwiftPath = path.join(
    repoRoot,
    'packages',
    'expo-updates',
    'ios',
    'EXUpdates',
    'E2ETestModule.swift'
  );
  const androidE2ETestModuleKTPath = path.join(
    repoRoot,
    'packages',
    'expo-updates',
    'android',
    'src',
    'main',
    'java',
    'expo',
    'modules',
    'updates',
    'UpdatesE2ETestModule.kt'
  );
  await fs.copyFile(
    path.resolve(dirName, '..', 'fixtures', 'E2ETestModule.swift'),
    iosE2ETestModuleSwiftPath
  );
  await fs.copyFile(
    path.resolve(dirName, '..', 'fixtures', 'UpdatesE2ETestModule.kt'),
    androidE2ETestModuleKTPath
  );

  // Add E2ETestModule to expo-module.config.json
  const expoModuleConfigFilePath = path.join(
    repoRoot,
    'packages',
    'expo-updates',
    'expo-module.config.json'
  );
  const originalExpoModuleConfigJsonString = await fs.readFile(expoModuleConfigFilePath, 'utf-8');
  const originalExpoModuleConfig = JSON.parse(originalExpoModuleConfigJsonString);
  const expoModuleConfig = {
    ...originalExpoModuleConfig,
    apple: {
      ...originalExpoModuleConfig.apple,
      modules: [...originalExpoModuleConfig.apple.modules, 'E2ETestModule'],
    },
    android: {
      ...originalExpoModuleConfig.android,
      modules: [
        ...originalExpoModuleConfig.android.modules,
        'expo.modules.updates.UpdatesE2ETestModule',
      ],
    },
  };
  await fs.writeFile(expoModuleConfigFilePath, JSON.stringify(expoModuleConfig, null, 2), 'utf-8');

  // Return cleanup function
  return async () => {
    await fs.writeFile(expoModuleConfigFilePath, originalExpoModuleConfigJsonString, 'utf-8');
    await fs.rm(iosE2ETestModuleSwiftPath, { force: true });
    await fs.rm(androidE2ETestModuleKTPath, { force: true });
  };
}

/**
 * Modifies app.json in the E2E test app to add the properties we need
 */
function transformAppJsonForE2E(
  appJson: any,
  projectName: string,
  runtimeVersion: string,
  isTV: boolean
) {
  const plugins: any[] = [
    'expo-updates',
    [
      '@config-plugins/detox',
      {
        skipProguard: true,
        subdomains: Array.from(new Set(['10.0.2.2', 'localhost', process.env.UPDATES_HOST])),
      },
    ],
  ];
  if (isTV) {
    plugins.push([
      '@react-native-tvos/config-tv',
      {
        isTV: true,
        tvosDeploymentTarget: '15.1',
        showVerboseWarnings: true,
      },
    ]);
  }
  return {
    ...appJson,
    expo: {
      ...appJson.expo,
      name: projectName,
      runtimeVersion,
      plugins,
      newArchEnabled: true,
      android: { ...appJson.expo.android, package: 'dev.expo.updatese2e' },
      ios: { ...appJson.expo.ios, bundleIdentifier: 'dev.expo.updatese2e' },
      updates: {
        ...appJson.expo.updates,
        url: `http://${process.env.UPDATES_HOST}:${process.env.UPDATES_PORT}/update`,
        assetPatternsToBeBundled: ['includedAssets/*'],
        useNativeDebug: true,
        requestHeaders: {
          'expo-channel-name': 'default',
        },
      },
      extra: {
        eas: {
          projectId: '55685a57-9cf3-442d-9ba8-65c7b39849ef',
        },
      },
    },
  };
}

export function transformAppJsonForE2EWithOldArch(
  appJson: any,
  projectName: string,
  runtimeVersion: string,
  isTV: boolean
) {
  const transformedForE2E = transformAppJsonForE2E(appJson, projectName, runtimeVersion, isTV);
  return {
    ...transformedForE2E,
    expo: {
      ...transformedForE2E.expo,
      newArchEnabled: false,
    },
  };
}

/**
 * Modifies app.json in the E2E test app to add the properties we need, and sets the runtime version policy to fingerprint
 */
export function transformAppJsonForE2EWithFingerprint(
  appJson: any,
  projectName: string,
  runtimeVersion: string,
  isTV: boolean
) {
  const transformedForE2E = transformAppJsonForE2EWithFallbackToCacheTimeout(
    appJson,
    projectName,
    runtimeVersion,
    isTV
  );
  return {
    ...transformedForE2E,
    expo: {
      ...transformedForE2E.expo,
      runtimeVersion: {
        policy: 'fingerprint',
      },
    },
  };
}

/**
 * Modifies app.json in the E2E test app to add the properties we need, and turns off updates native debug
 */
export function transformAppJsonForE2EWithDevClient(
  appJson: any,
  projectName: string,
  runtimeVersion: string,
  isTV: boolean
) {
  const transformedForE2E = transformAppJsonForE2EWithFallbackToCacheTimeout(
    appJson,
    projectName,
    runtimeVersion,
    isTV
  );
  delete transformedForE2E.expo.updates.useNativeDebug;
  return transformedForE2E;
}

export function transformAppJsonForE2EWithBrickingMeasuresDisabled(
  appJson: any,
  projectName: string,
  runtimeVersion: string,
  isTV: boolean
) {
  const transformedForE2E = transformAppJsonForE2EWithFallbackToCacheTimeout(
    appJson,
    projectName,
    runtimeVersion,
    isTV
  );
  return {
    ...transformedForE2E,
    expo: {
      ...transformedForE2E.expo,
      updates: {
        ...transformedForE2E.expo.updates,
        disableAntiBrickingMeasures: true,
      },
    },
  };
}

/**
 * Modifies app.json in the E2E test app to add the properties we need, plus a fallback to cache timeout for testing startup procedure
 */
export function transformAppJsonForE2EWithFallbackToCacheTimeout(
  appJson: any,
  projectName: string,
  runtimeVersion: string,
  isTV: boolean
) {
  const transformedForE2E = transformAppJsonForE2E(appJson, projectName, runtimeVersion, isTV);
  return {
    ...transformedForE2E,
    expo: {
      ...transformedForE2E.expo,
      updates: {
        ...transformedForE2E.expo.updates,
        fallbackToCacheTimeout: 5000,
      },
    },
  };
}

/**
 * Modifies app.json in the updates-disabled E2E test app to add the properties we need
 */
export function transformAppJsonForUpdatesDisabledE2E(
  appJson: any,
  projectName: string,
  runtimeVersion: string
) {
  const plugins: any[] = [
    'expo-updates',
    [
      '@config-plugins/detox',
      {
        skipProguard: true,
        subdomains: Array.from(new Set(['10.0.2.2', 'localhost', process.env.UPDATES_HOST])),
      },
    ],
  ];
  return {
    ...appJson,
    expo: {
      ...appJson.expo,
      name: projectName,
      runtimeVersion,
      plugins,
      newArchEnabled: true,
      android: { ...appJson.expo.android, package: 'dev.expo.updatese2e' },
      ios: { ...appJson.expo.ios, bundleIdentifier: 'dev.expo.updatese2e' },
      updates: {
        enabled: false,
        useNativeDebug: true,
      },
      extra: {
        eas: {
          projectId: '55685a57-9cf3-442d-9ba8-65c7b39849ef',
        },
      },
    },
  };
}

async function configureUpdatesSigningAsync(projectRoot: string) {
  console.time('generate and configure code signing');
  // generate and configure code signing
  await spawnAsync(
    'yarn',
    [
      'expo-updates',
      'codesigning:generate',
      '--key-output-directory',
      'keys',
      '--certificate-output-directory',
      'certs',
      '--certificate-validity-duration-years',
      '1',
      '--certificate-common-name',
      'E2E Test App',
    ],
    { cwd: projectRoot, stdio: 'inherit' }
  );

  await spawnAsync(
    'yarn',
    [
      'expo-updates',
      'codesigning:configure',
      '--certificate-input-directory',
      'certs',
      '--key-input-directory',
      'keys',
    ],
    { cwd: projectRoot, stdio: 'inherit' }
  );
  // Archive the keys so that they are not filtered out when uploading to EAS
  await spawnAsync('tar', ['cf', 'keys.tar', 'keys'], { cwd: projectRoot, stdio: 'inherit' });

  console.timeEnd('generate and configure code signing');
}

export async function initAsync(
  projectRoot: string,
  {
    repoRoot,
    runtimeVersion,
    localCliBin,
    configureE2E = true,
    transformAppJson = transformAppJsonForE2E,
    isTV = false,
    shouldGenerateTestUpdateBundles = true,
    shouldConfigureCodeSigning = true,
    includeDevClient = false,
    useCustomInit = false,
  }: {
    repoRoot: string;
    runtimeVersion: string;
    localCliBin: string;
    configureE2E?: boolean;
    transformAppJson?: (
      appJson: any,
      projectName: string,
      runtimeVersion: string,
      isTV: boolean
    ) => void;
    isTV?: boolean;
    shouldGenerateTestUpdateBundles?: boolean;
    shouldConfigureCodeSigning?: boolean;
    includeDevClient?: boolean;
    useCustomInit?: boolean;
  }
) {
  console.log('Creating expo app');
  const workingDir = path.dirname(projectRoot);
  const projectName = path.basename(projectRoot);

  if (!process.env.CI && existsSync(projectRoot)) {
    console.log(`Deleting existing project at ${projectRoot}...`);
    rmSync(projectRoot, { recursive: true, force: true });
  }

  // pack typescript template
  const templateName = 'expo-template-blank-typescript';
  const localTSTemplatePath = path.join(repoRoot, 'templates', templateName);
  const localTSTemplatePathName = await spawnNpmPackAsync({
    cwd: localTSTemplatePath,
    outputDir: repoRoot,
  });

  // initialize project (do not do NPM install, we do that later)
  await spawnAsync(
    'yarn',
    [
      'create',
      'expo-app',
      projectName,
      '--yes',
      '--no-install',
      '--template',
      localTSTemplatePathName,
    ],
    {
      cwd: workingDir,
      stdio: 'inherit',
    }
  );

  // We are done with template tarball
  await fs.rm(localTSTemplatePathName);

  let cleanupLocalUpdatesModule: (() => Promise<void>) | undefined;
  if (configureE2E) {
    cleanupLocalUpdatesModule = await prepareLocalUpdatesModule(repoRoot);
  }

  await preparePackageJson(
    projectRoot,
    repoRoot,
    configureE2E,
    isTV,
    shouldGenerateTestUpdateBundles,
    includeDevClient,
    useCustomInit
  );

  // configure app.json
  let appJson = JSON.parse(await fs.readFile(path.join(projectRoot, 'app.json'), 'utf-8'));
  appJson = transformAppJson(appJson, projectName, runtimeVersion, isTV);
  await fs.writeFile(path.join(projectRoot, 'app.json'), JSON.stringify(appJson, null, 2), 'utf-8');

  // Install node modules with local tarballs
  await spawnAsync('yarn', [], {
    cwd: projectRoot,
    stdio: 'inherit',
  });

  if (configureE2E && shouldConfigureCodeSigning) {
    await configureUpdatesSigningAsync(projectRoot);
  }

  // pack local template and prebuild, but do not reinstall NPM
  const prebuildTemplateName = 'expo-template-bare-minimum';

  const localTemplatePath = path.join(repoRoot, 'templates', prebuildTemplateName);
  const localTemplatePathName = await spawnNpmPackAsync({
    cwd: localTemplatePath,
    outputDir: projectRoot,
  });

  await spawnAsync(localCliBin, ['prebuild', '--no-install', '--template', localTemplatePathName], {
    env: {
      ...process.env,
      EXPO_DEBUG: '1',
      CI: '1',
    },
    cwd: projectRoot,
    stdio: 'inherit',
  });

  // We are done with template tarball
  await fs.rm(localTemplatePathName);

  // Restore expo dependencies after prebuild
  const packageJsonPath = path.resolve(projectRoot, 'package.json');
  let packageJsonString = await fs.readFile(packageJsonPath, 'utf-8');
  const packageJson = JSON.parse(packageJsonString);
  packageJson.dependencies.expo = packageJson.resolutions.expo;
  packageJsonString = JSON.stringify(packageJson, null, 2);
  await fs.rm(packageJsonPath);
  await fs.writeFile(packageJsonPath, packageJsonString, 'utf-8');
  await spawnAsync('yarn', [], {
    cwd: projectRoot,
    stdio: 'inherit',
  });

  // enable proguard on Android, and custom init if needed
  await fs.appendFile(
    path.join(projectRoot, 'android', 'gradle.properties'),
    `\nandroid.enableMinifyInReleaseBuilds=true${useCustomInit ? '\nEX_UPDATES_CUSTOM_INIT=true' : ''}`,
    'utf-8'
  );

  // Append additional Proguard rule
  await fs.appendFile(
    path.join(projectRoot, 'android', 'app', 'proguard-rules.pro'),
    [
      '',
      '-keep class org.apache.commons.** { *; }',
      '-dontwarn androidx.appcompat.graphics.drawable.DrawableWrapper',
      '-dontwarn com.facebook.react.views.slider.**',
      '-dontwarn javax.lang.model.element.Modifier',
      '-dontwarn org.checkerframework.checker.nullness.qual.EnsuresNonNullIf',
      '-dontwarn org.checkerframework.dataflow.qual.Pure',
      '-keep class com.google.common.util.concurrent.ListenableFuture { *; }',
      '',
    ].join('\n'),
    'utf-8'
  );

  // Add custom init to iOS Podfile.properties.json if needed
  if (useCustomInit) {
    const podfilePropertiesJsonPath = path.join(projectRoot, 'ios', 'Podfile.properties.json');
    const podfilePropertiesJsonString = await fs.readFile(podfilePropertiesJsonPath, {
      encoding: 'utf-8',
    });
    const podfilePropertiesJson: any = JSON.parse(podfilePropertiesJsonString);
    podfilePropertiesJson.updatesCustomInit = 'true';
    await fs.writeFile(podfilePropertiesJsonPath, JSON.stringify(podfilePropertiesJson, null, 2), {
      encoding: 'utf-8',
    });
  }

  const customInitSourcesDirectory = path.join(
    repoRoot,
    'packages',
    'expo-updates',
    'e2e',
    'fixtures',
    'custom_init'
  );
  // If custom init, copy native source files
  if (useCustomInit) {
    const filesToCopyForCustomInit = [
      {
        sourcePath: path.join(customInitSourcesDirectory, 'AppDelegate.swift'),
        destPath: path.join(projectRoot, 'ios', 'updatese2e', 'AppDelegate.swift'),
      },
      {
        sourcePath: path.join(customInitSourcesDirectory, 'MainApplication.kt'),
        destPath: path.join(
          projectRoot,
          'android',
          'app',
          'src',
          'main',
          'java',
          'dev',
          'expo',
          'updatese2e',
          'MainApplication.kt'
        ),
      },
      {
        sourcePath: path.join(customInitSourcesDirectory, 'MainActivity.kt'),
        destPath: path.join(
          projectRoot,
          'android',
          'app',
          'src',
          'main',
          'java',
          'dev',
          'expo',
          'updatese2e',
          'MainActivity.kt'
        ),
      },
    ];
    for (const fileToCopy of filesToCopyForCustomInit) {
      await fs.copyFile(fileToCopy.sourcePath, fileToCopy.destPath);
    }
  }

  // Cleanup local updates module if needed
  if (cleanupLocalUpdatesModule) {
    await cleanupLocalUpdatesModule();
  }

  return projectRoot;
}

export async function setupE2EAppAsync(
  projectRoot: string,
  { localCliBin, repoRoot, isTV = false }: { localCliBin: string; repoRoot: string; isTV?: boolean }
) {
  await copyCommonFixturesToProject(
    projectRoot,
    ['tsconfig.json', '.env', 'eas.json', 'maestro', 'includedAssets', 'scripts'],
    { appJsFileName: 'App.tsx', repoRoot, isTV }
  );

  // install extra fonts package
  await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
    cwd: projectRoot,
    stdio: 'inherit',
  });
}

export async function setupManualTestAppAsync(projectRoot: string, repoRoot: string) {
  // Copy API test app and other fixtures to project
  await copyCommonFixturesToProject(
    projectRoot,
    ['tsconfig.json', 'assetsInUpdates', 'embeddedAssets', 'scripts'],
    { appJsFileName: 'App-apitest.tsx', repoRoot, isTV: false }
  );

  const projectName = path.basename(projectRoot);

  // disable JS debugging on Android
  const mainApplicationPath = path.join(
    projectRoot,
    'android',
    'app',
    'src',
    'main',
    'java',
    'com',
    EXPO_ACCOUNT_NAME,
    projectName,
    'MainApplication.kt'
  );
  const mainApplicationText = await fs.readFile(mainApplicationPath, { encoding: 'utf-8' });
  const mainApplicationTextModified = mainApplicationText.replace('BuildConfig.DEBUG', 'false');
  await fs.writeFile(mainApplicationPath, mainApplicationTextModified, { encoding: 'utf-8' });
}

export async function setupUpdatesDisabledE2EAppAsync(
  projectRoot: string,
  { localCliBin, repoRoot }: { localCliBin: string; repoRoot: string }
) {
  await copyCommonFixturesToProject(
    projectRoot,
    ['tsconfig.json', '.env', 'eas.json', 'maestro', 'includedAssets', 'scripts'],
    {
      appJsFileName: 'App-updates-disabled.tsx',
      repoRoot,
      isTV: false,
    }
  );

  // install extra fonts package
  await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
    cwd: projectRoot,
    stdio: 'inherit',
  });
}

export async function setupUpdatesErrorRecoveryE2EAppAsync(
  projectRoot: string,
  { localCliBin, repoRoot }: { localCliBin: string; repoRoot: string }
) {
  await copyCommonFixturesToProject(
    projectRoot,
    ['tsconfig.json', '.env', 'eas.json', 'maestro', 'includedAssets', 'scripts'],
    { appJsFileName: 'App.tsx', repoRoot, isTV: false }
  );

  // install extra fonts package
  await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
    cwd: projectRoot,
    stdio: 'inherit',
  });
}

export async function setupUpdatesFingerprintE2EAppAsync(
  projectRoot: string,
  { localCliBin, repoRoot }: { localCliBin: string; repoRoot: string }
) {
  await copyCommonFixturesToProject(
    projectRoot,
    [
      'tsconfig.json',
      '.fingerprintignore',
      '.env',
      'eas.json',
      'maestro',
      'includedAssets',
      'scripts',
    ],
    { appJsFileName: 'App.tsx', repoRoot, isTV: false }
  );

  // install extra fonts package
  await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
    cwd: projectRoot,
    stdio: 'inherit',
  });
}

export async function setupUpdatesStartupE2EAppAsync(
  projectRoot: string,
  { localCliBin, repoRoot }: { localCliBin: string; repoRoot: string }
) {
  await copyCommonFixturesToProject(
    projectRoot,
    ['tsconfig.json', '.env', 'eas.json', 'maestro', 'includedAssets', 'scripts'],
    { appJsFileName: 'App.tsx', repoRoot, isTV: false }
  );

  // install extra fonts package
  await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
    cwd: projectRoot,
    stdio: 'inherit',
  });
}

export async function setupUpdatesBrickingMeasuresDisabledE2EAppAsync(
  projectRoot: string,
  { localCliBin, repoRoot }: { localCliBin: string; repoRoot: string }
) {
  await copyCommonFixturesToProject(
    projectRoot,
    ['tsconfig.json', '.env', 'eas.json', 'maestro', 'includedAssets', 'scripts'],
    { appJsFileName: 'App.tsx', repoRoot, isTV: false }
  );

  // install extra fonts package
  await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
    cwd: projectRoot,
    stdio: 'inherit',
  });
}

export async function setupUpdatesDevClientE2EAppAsync(
  projectRoot: string,
  { localCliBin, repoRoot, isTV }: { localCliBin: string; repoRoot: string; isTV?: boolean }
) {
  await copyCommonFixturesToProject(
    projectRoot,
    ['tsconfig.json', '.env', 'eas.json', 'maestro', 'includedAssets', 'scripts'],
    { appJsFileName: 'App.tsx', repoRoot, isTV: isTV ?? false }
  );

  // install extra fonts package
  await spawnAsync(localCliBin, ['install', '@expo-google-fonts/inter'], {
    cwd: projectRoot,
    stdio: 'inherit',
  });
}
